Breaking AES-256 Bootloader

This tutorial will take you through a complete attack on an encrypted bootloader using AES-256. This demonstrates how to use side-channel power analysis on practical systems, along with discussing how to perform analysis with different Analyzer models.

In [1]:
SCOPETYPE = 'OPENADC'
PLATFORM = 'CWLITEARM'

Background

In the world of microcontrollers, a bootloader is a special piece of firmware that is made to let the user upload new programs into memory. This is especially useful for devices with complex code that may need to be patched or otherwise updated in the future - a bootloader makes it possible for the user to upload a patched version of the firmware onto the micro. The bootloader receives information from a communication line (a USB port, serial port, ethernet port, WiFi connection, etc...) and stores this data into program memory. Once the full firmware has been received, the micro can happily run its updated code.

There is one big security issue to worry about with bootloaders. A company may want to stop their customers from writing their own firmware and uploading it onto the micro. For example, this might be for protection reasons - hackers might be able to access parts of the device that weren't meant to be accessed. One way of stopping this is to add encryption. The company can add their own secret signature to the firmware code and encrypt it with a secret key. Then, the bootloader can decrypt the incoming firmware and confirm that the incoming firmware is correctly signed. Users will not know the secret key or the signature tied to the firmware, so they won't be able to "fake" their own.

This tutorial will work with a simple AES-256 bootloader. The victim will receive data through a serial connection, decrypt the command, and confirm that the included signature is correct. Then, it will only save the code into memory if the signature check succeeded. To make this system more robust against attacks, the bootloader will use cipher-block chaining (CBC mode). Our goal is to find the secret key and the CBC initialization vector so that we could successfully fake our own firmware.

Bootloader Communications Protocol

The bootloader's communications protocol operates over a serial port at 38400 baud rate. The bootloader is always waiting for new data to be sent in this example; in real life one would typically force the bootloader to enter through a command sequence.

Commands sent to the bootloader look as follows:

       |<-------- Encrypted block (16 bytes) ---------->|
       |                                                |
+------+------+------+------+------+------+ .... +------+------+------+
| 0x00 |    Signature (4 Bytes)    |  Data (12 Bytes)   |   CRC-16    |
+------+------+------+------+------+------+ .... +------+------+------+

This frame has four parts:

  • 0x00: 1 byte of fixed header
  • Signature: A secret 4 byte constant. The bootloader will confirm that this signature is correct after decrypting the frame.
  • Data: 12 bytes of the incoming firmware. This system forces us to send the code 12 bytes at a time; more complete bootloaders may allow longer variable-length frames.
  • CRC-16: A 16-bit checksum using the CRC-CCITT polynomial (0x1021). The LSB of the CRC is sent first, followed by the MSB. The bootloader will reply over the serial port, describing whether or not this CRC check was valid.

As described in the diagram, the 16 byte block is not sent as plaintext. Instead, it is encrypted using AES-256 in CBC mode. This encryption method will be described in the next section.

The bootloader responds to each command with a single byte indicating if the CRC-16 was OK or not:

            +------+
CRC-OK:     | 0xA1 |
            +------+

            +------+
CRC Failed: | 0xA4 |
            +------+

Then, after replying to the command, the bootloader veries that the signature is correct. If it matches the expected manufacturer's signature, the 12 bytes of data will be written to flash memory. Otherwise, the data is discarded.

Details of AES-256 CBC

The system uses the AES algorithm in Cipher Block Chaining (CBC) mode. In general one avoids using encryption 'as-is' (i.e. Electronic Code Book), since it means any piece of plaintext always maps to the same piece of ciphertext. Cipher Block Chaining ensures that if you encrypted the same thing a bunch of times it would always encrypt to a new piece of ciphertext.

You can see another reference on the design of the encryption side; we'll be only talking about the decryption side here. In this case AES-256 CBC mode is used as follows, where the details of the AES-256 Decryption block will be discussed in detail later:

AES-256

This diagram shows that the output of the decryption is no longer used directly as the plaintext. Instead, the output is XORed with a 16 byte mask, which is usually taken from the previous ciphertext. Also, the first decryption block has no previous ciphertext to use, so a secret initialization vector (IV) is used instead. If we are going to decrypt the entire ciphertext (including block 0) or correctly generate our own ciphertext, we'll need to find this IV along with the AES key.

Attacking AES-256

The system in this tutorial uses AES-256 encryption, which has a 256 bit (32 byte) key - twice as large as the 16 byte key we've attacked in previous tutorials. This means that our regular AES-128 CPA attacks won't quite work. However, extending these attacks to AES-256 is fairly straightforward: the theory is explained in detail in Extending AES-128 Attacks to AES-256.

As the theory page explains, our AES-256 attack will have 4 steps:

  1. Perform a standard attack (as in AES-128 decryption) to determine the first 16 bytes of the key, corresponding to the 14th round encryption key.
  2. Using the known 14th round key, calculate the hypothetical outputs of each S-Box from the 13th round using the ciphertext processed by the 14th round, and determine the 16 bytes of the 13th round key manipulated by inverse MixColumns.
  3. Perform the MixColumns and ShiftRows operation on the hypothetical key determined above, recovering the 13th round key.
  4. Using the AES-256 key schedule, reverse the 13th and 14th round keys to determine the original AES-256 encryption key.

Firmware

For this tutorial, we'll be using the bootloader-aes256 project, which we'll build as usual:

In [2]:
%%bash -s "$PLATFORM" 
cd ../hardware/victims/firmware/bootloader-aes256
make PLATFORM=$1 CRYPTO_TARGET=NONE
rm -f -- bootloader-aes256-CWLITEARM.hex

rm -f -- bootloader-aes256-CWLITEARM.eep

rm -f -- bootloader-aes256-CWLITEARM.cof

rm -f -- bootloader-aes256-CWLITEARM.elf

rm -f -- bootloader-aes256-CWLITEARM.map

rm -f -- bootloader-aes256-CWLITEARM.sym

rm -f -- bootloader-aes256-CWLITEARM.lss

rm -f -- objdir/*.o

rm -f -- objdir/*.lst

rm -f -- bootloader.s aes256.s crcccitt.s simpleserial.s stm32f3_hal.s stm32f3_hal_lowlevel.s stm32f3_sysmem.s

rm -f -- bootloader.d aes256.d crcccitt.d simpleserial.d stm32f3_hal.d stm32f3_hal_lowlevel.d stm32f3_sysmem.d

rm -f -- bootloader.i aes256.i crcccitt.i simpleserial.i stm32f3_hal.i stm32f3_hal_lowlevel.i stm32f3_sysmem.i

.

-------- begin --------

arm-none-eabi-gcc (GNU Tools for Arm Embedded Processors 7-2018-q2-update) 7.3.1 20180622 (release) [ARM/embedded-7-branch revision 261907]

Copyright (C) 2017 Free Software Foundation, Inc.

This is free software; see the source for copying conditions.  There is NO

warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.



.

Compiling C: bootloader.c

arm-none-eabi-gcc -c -mcpu=cortex-m4 -I. -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16 -fmessage-length=0 -ffunction-sections -gdwarf-2 -DSS_VER=SS_VER_1_1 -DSTM32F303xC -DSTM32F3 -DSTM32 -DDEBUG -DHAL_TYPE=HAL_stm32f3 -DPLATFORM=CWLITEARM -DF_CPU=7372800UL -Os -funsigned-char -funsigned-bitfields -fshort-enums -Wall -Wstrict-prototypes -Wa,-adhlns=objdir/bootloader.lst -I.././simpleserial/ -I.././hal -I.././hal/stm32f3 -I.././hal/stm32f3/CMSIS -I.././hal/stm32f3/CMSIS/core -I.././hal/stm32f3/CMSIS/device -I.././hal/stm32f4/Legacy -I.././crypto/ -std=gnu99 -MMD -MP -MF .dep/bootloader.o.d bootloader.c -o objdir/bootloader.o 

.

Compiling C: aes256.c

arm-none-eabi-gcc -c -mcpu=cortex-m4 -I. -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16 -fmessage-length=0 -ffunction-sections -gdwarf-2 -DSS_VER=SS_VER_1_1 -DSTM32F303xC -DSTM32F3 -DSTM32 -DDEBUG -DHAL_TYPE=HAL_stm32f3 -DPLATFORM=CWLITEARM -DF_CPU=7372800UL -Os -funsigned-char -funsigned-bitfields -fshort-enums -Wall -Wstrict-prototypes -Wa,-adhlns=objdir/aes256.lst -I.././simpleserial/ -I.././hal -I.././hal/stm32f3 -I.././hal/stm32f3/CMSIS -I.././hal/stm32f3/CMSIS/core -I.././hal/stm32f3/CMSIS/device -I.././hal/stm32f4/Legacy -I.././crypto/ -std=gnu99 -MMD -MP -MF .dep/aes256.o.d aes256.c -o objdir/aes256.o 

.

Compiling C: crcccitt.c

arm-none-eabi-gcc -c -mcpu=cortex-m4 -I. -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16 -fmessage-length=0 -ffunction-sections -gdwarf-2 -DSS_VER=SS_VER_1_1 -DSTM32F303xC -DSTM32F3 -DSTM32 -DDEBUG -DHAL_TYPE=HAL_stm32f3 -DPLATFORM=CWLITEARM -DF_CPU=7372800UL -Os -funsigned-char -funsigned-bitfields -fshort-enums -Wall -Wstrict-prototypes -Wa,-adhlns=objdir/crcccitt.lst -I.././simpleserial/ -I.././hal -I.././hal/stm32f3 -I.././hal/stm32f3/CMSIS -I.././hal/stm32f3/CMSIS/core -I.././hal/stm32f3/CMSIS/device -I.././hal/stm32f4/Legacy -I.././crypto/ -std=gnu99 -MMD -MP -MF .dep/crcccitt.o.d crcccitt.c -o objdir/crcccitt.o 

.

Compiling C: .././simpleserial/simpleserial.c

arm-none-eabi-gcc -c -mcpu=cortex-m4 -I. -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16 -fmessage-length=0 -ffunction-sections -gdwarf-2 -DSS_VER=SS_VER_1_1 -DSTM32F303xC -DSTM32F3 -DSTM32 -DDEBUG -DHAL_TYPE=HAL_stm32f3 -DPLATFORM=CWLITEARM -DF_CPU=7372800UL -Os -funsigned-char -funsigned-bitfields -fshort-enums -Wall -Wstrict-prototypes -Wa,-adhlns=objdir/simpleserial.lst -I.././simpleserial/ -I.././hal -I.././hal/stm32f3 -I.././hal/stm32f3/CMSIS -I.././hal/stm32f3/CMSIS/core -I.././hal/stm32f3/CMSIS/device -I.././hal/stm32f4/Legacy -I.././crypto/ -std=gnu99 -MMD -MP -MF .dep/simpleserial.o.d .././simpleserial/simpleserial.c -o objdir/simpleserial.o 

.

Compiling C: .././hal/stm32f3/stm32f3_hal.c

arm-none-eabi-gcc -c -mcpu=cortex-m4 -I. -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16 -fmessage-length=0 -ffunction-sections -gdwarf-2 -DSS_VER=SS_VER_1_1 -DSTM32F303xC -DSTM32F3 -DSTM32 -DDEBUG -DHAL_TYPE=HAL_stm32f3 -DPLATFORM=CWLITEARM -DF_CPU=7372800UL -Os -funsigned-char -funsigned-bitfields -fshort-enums -Wall -Wstrict-prototypes -Wa,-adhlns=objdir/stm32f3_hal.lst -I.././simpleserial/ -I.././hal -I.././hal/stm32f3 -I.././hal/stm32f3/CMSIS -I.././hal/stm32f3/CMSIS/core -I.././hal/stm32f3/CMSIS/device -I.././hal/stm32f4/Legacy -I.././crypto/ -std=gnu99 -MMD -MP -MF .dep/stm32f3_hal.o.d .././hal/stm32f3/stm32f3_hal.c -o objdir/stm32f3_hal.o 

.

Compiling C: .././hal/stm32f3/stm32f3_hal_lowlevel.c

arm-none-eabi-gcc -c -mcpu=cortex-m4 -I. -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16 -fmessage-length=0 -ffunction-sections -gdwarf-2 -DSS_VER=SS_VER_1_1 -DSTM32F303xC -DSTM32F3 -DSTM32 -DDEBUG -DHAL_TYPE=HAL_stm32f3 -DPLATFORM=CWLITEARM -DF_CPU=7372800UL -Os -funsigned-char -funsigned-bitfields -fshort-enums -Wall -Wstrict-prototypes -Wa,-adhlns=objdir/stm32f3_hal_lowlevel.lst -I.././simpleserial/ -I.././hal -I.././hal/stm32f3 -I.././hal/stm32f3/CMSIS -I.././hal/stm32f3/CMSIS/core -I.././hal/stm32f3/CMSIS/device -I.././hal/stm32f4/Legacy -I.././crypto/ -std=gnu99 -MMD -MP -MF .dep/stm32f3_hal_lowlevel.o.d .././hal/stm32f3/stm32f3_hal_lowlevel.c -o objdir/stm32f3_hal_lowlevel.o 

.

Compiling C: .././hal/stm32f3/stm32f3_sysmem.c

arm-none-eabi-gcc -c -mcpu=cortex-m4 -I. -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16 -fmessage-length=0 -ffunction-sections -gdwarf-2 -DSS_VER=SS_VER_1_1 -DSTM32F303xC -DSTM32F3 -DSTM32 -DDEBUG -DHAL_TYPE=HAL_stm32f3 -DPLATFORM=CWLITEARM -DF_CPU=7372800UL -Os -funsigned-char -funsigned-bitfields -fshort-enums -Wall -Wstrict-prototypes -Wa,-adhlns=objdir/stm32f3_sysmem.lst -I.././simpleserial/ -I.././hal -I.././hal/stm32f3 -I.././hal/stm32f3/CMSIS -I.././hal/stm32f3/CMSIS/core -I.././hal/stm32f3/CMSIS/device -I.././hal/stm32f4/Legacy -I.././crypto/ -std=gnu99 -MMD -MP -MF .dep/stm32f3_sysmem.o.d .././hal/stm32f3/stm32f3_sysmem.c -o objdir/stm32f3_sysmem.o 

.

Assembling: .././hal/stm32f3/stm32f3_startup.S

arm-none-eabi-gcc -c -mcpu=cortex-m4 -I. -x assembler-with-cpp -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16 -fmessage-length=0 -ffunction-sections -DF_CPU=7372800 -Wa,-gstabs,-adhlns=objdir/stm32f3_startup.lst -I.././simpleserial/ -I.././hal -I.././hal/stm32f3 -I.././hal/stm32f3/CMSIS -I.././hal/stm32f3/CMSIS/core -I.././hal/stm32f3/CMSIS/device -I.././hal/stm32f4/Legacy -I.././crypto/ .././hal/stm32f3/stm32f3_startup.S -o objdir/stm32f3_startup.o

.

Linking: bootloader-aes256-CWLITEARM.elf

arm-none-eabi-gcc -mcpu=cortex-m4 -I. -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16 -fmessage-length=0 -ffunction-sections -gdwarf-2 -DSS_VER=SS_VER_1_1 -DSTM32F303xC -DSTM32F3 -DSTM32 -DDEBUG -DHAL_TYPE=HAL_stm32f3 -DPLATFORM=CWLITEARM -DF_CPU=7372800UL -Os -funsigned-char -funsigned-bitfields -fshort-enums -Wall -Wstrict-prototypes -Wa,-adhlns=objdir/bootloader.o -I.././simpleserial/ -I.././hal -I.././hal/stm32f3 -I.././hal/stm32f3/CMSIS -I.././hal/stm32f3/CMSIS/core -I.././hal/stm32f3/CMSIS/device -I.././hal/stm32f4/Legacy -I.././crypto/ -std=gnu99 -MMD -MP -MF .dep/bootloader-aes256-CWLITEARM.elf.d objdir/bootloader.o objdir/aes256.o objdir/crcccitt.o objdir/simpleserial.o objdir/stm32f3_hal.o objdir/stm32f3_hal_lowlevel.o objdir/stm32f3_sysmem.o objdir/stm32f3_startup.o --output bootloader-aes256-CWLITEARM.elf --specs=nano.specs -T .././hal/stm32f3/LinkerScript.ld -Wl,--gc-sections -lm -Wl,-Map=bootloader-aes256-CWLITEARM.map,--cref   -lm  

.

Creating load file for Flash: bootloader-aes256-CWLITEARM.hex

arm-none-eabi-objcopy -O ihex -R .eeprom -R .fuse -R .lock -R .signature bootloader-aes256-CWLITEARM.elf bootloader-aes256-CWLITEARM.hex

.

Creating load file for EEPROM: bootloader-aes256-CWLITEARM.eep

arm-none-eabi-objcopy -j .eeprom --set-section-flags=.eeprom="alloc,load" \

	--change-section-lma .eeprom=0 --no-change-warnings -O ihex bootloader-aes256-CWLITEARM.elf bootloader-aes256-CWLITEARM.eep || exit 0

.

Creating Extended Listing: bootloader-aes256-CWLITEARM.lss

arm-none-eabi-objdump -h -S -z bootloader-aes256-CWLITEARM.elf > bootloader-aes256-CWLITEARM.lss

.

Creating Symbol Table: bootloader-aes256-CWLITEARM.sym

arm-none-eabi-nm -n bootloader-aes256-CWLITEARM.elf > bootloader-aes256-CWLITEARM.sym

Size after:

   text	   data	    bss	    dec	    hex	filename

   5836	      8	   2208	   8052	   1f74	bootloader-aes256-CWLITEARM.elf

+--------------------------------------------------------

+ Built for platform CW-Lite Arm (STM32F3)

+--------------------------------------------------------

Capturing Traces

Setup

To start, we'll proceed with setup as usual:

In [3]:
%run "Helper_Scripts/Setup.ipynb"
In [4]:
fw_path = "../hardware/victims/firmware/bootloader-aes256/bootloader-aes256-{}.hex".format(PLATFORM)
In [5]:
cw.program_target(scope, prog, fw_path)
Detected known STMF32: STM32F302xB(C)/303xB(C)
Extended erase (0x44), this can take ten seconds or more
Attempting to programming 5843 bytes at 0x8000000
STM32F Programming flash...
STM32F Reading flash...
Verified flash OK, 5843 bytes

Calculating the CRC

The next step we'll need to take in attacking this target is to communicate with it. Most of the transmission is fairly straight forward, but the CRC is a little tricky. Luckily, there's a lot of open source out there for calculating CRCs. In this case, we'll pull some code from pycrc:

In [6]:
# Class Crc
#############################################################
# These CRC routines are copy-pasted from pycrc, which are:
# Copyright (c) 2006-2013 Thomas Pircher <tehpeh@gmx.net>
#
class Crc(object):
    """
    A base class for CRC routines.
    """

    def __init__(self, width, poly):
        """The Crc constructor.

        The parameters are as follows:
            width
            poly
            reflect_in
            xor_in
            reflect_out
            xor_out
        """
        self.Width = width
        self.Poly = poly


        self.MSB_Mask = 0x1 << (self.Width - 1)
        self.Mask = ((self.MSB_Mask - 1) << 1) | 1

        self.XorIn = 0x0000
        self.XorOut = 0x0000

        self.DirectInit = self.XorIn
        self.NonDirectInit = self.__get_nondirect_init(self.XorIn)
        if self.Width < 8:
            self.CrcShift = 8 - self.Width
        else:
            self.CrcShift = 0

    def __get_nondirect_init(self, init):
        """
        return the non-direct init if the direct algorithm has been selected.
        """
        crc = init
        for i in range(self.Width):
            bit = crc & 0x01
            if bit:
                crc ^= self.Poly
            crc >>= 1
            if bit:
                crc |= self.MSB_Mask
        return crc & self.Mask


    def bit_by_bit(self, in_data):
        """
        Classic simple and slow CRC implementation.  This function iterates bit
        by bit over the augmented input message and returns the calculated CRC
        value at the end.
        """
        # If the input data is a string, convert to bytes.
        if isinstance(in_data, str):
            in_data = [ord(c) for c in in_data]

        register = self.NonDirectInit
        for octet in in_data:
            for i in range(8):
                topbit = register & self.MSB_Mask
                register = ((register << 1) & self.Mask) | ((octet >> (7 - i)) & 0x01)
                if topbit:
                    register ^= self.Poly

        for i in range(self.Width):
            topbit = register & self.MSB_Mask
            register = ((register << 1) & self.Mask)
            if topbit:
                register ^= self.Poly

        return register ^ self.XorOut
    
bl_crc = Crc(width = 16, poly=0x1021)

Now we can easily get the CRC for our message by calling bl_crc.bit_by_bit(message).

Communicating with the Bootloader

With that done, we can start communicating with the bootloader. Recall that the bootloader expects:

  • To start with 0x00
  • A 16 byte encrypted message (4 bytes signature + 12 bytes data)
  • CRC16

We don't really care what the 16 byte message is (just that each is different so that we get a variety of hamming weights), so we'll use the same text/key module from earlier attacks.

We can now run the following block, and we should get 0xA4 back. You may need to run this block a few times to get the right response back.

In [7]:
import time
okay = 0
reset_target(scope)

while not okay:
    target.write('\0xxxxxxxxxxxxxxxxxx')
    time.sleep(0.05)
    response = target.read()
    if response:
        print(response)
        if ord(response[0]) == 0xA1:
            okay = 1

¡¡
In [8]:
import time
message = [0x00]
ktp = cw.ktp.Basic(target=target)

# clear serial buffer
print(target.read())

key, text = ktp.new_pair() #don't care about key here
message.extend(text)

crc = bl_crc.bit_by_bit(text)

message.append(crc >> 8)
message.append(crc & 0xFF)

target.write(message)
time.sleep(0.1)

response = target.read()
print("Response: {:02X}".format(ord(response[0])))
Response: A4

Capturing Traces

With that out of the way, we can proceed to capturing our traces. The normal 5000 traces we capture isn't long enough to get the rounds we care about, so we'll need to increase it (15000 should be fine):

In [9]:
scope.adc.samples = 15000

We'll be working with Analyzer, so we'll need to use a ChipWhisperer project to store our traces and text:

In [10]:
project = cw.create_project("projects/Tutorial_A5.cwp", overwrite=True)
tc = project.get_new_trace_segment()
ktp = cw.ktp.Basic(target=target)

Below you'll find our capture loop. This will be pretty similar to Tutorial B5, but we've added our communication code. We also check the response and just skip the data we get if it isn't correct.

In [11]:
#Capture Traces
from tqdm import tnrange
import numpy as np
import time
N = 200  # Number of traces
for i in tnrange(N, desc='Capturing traces'):
    message = [0x00]
    target.read()
    
    key, text = ktp.new_pair()
    message.extend(text)
    
    crc = bl_crc.bit_by_bit(text)
    message.append(crc >> 8)
    message.append(crc & 0xFF)

    scope.arm()

    target.write(message)
    
    ret = scope.capture()
    if ret:
        print('Timeout happened during acquisition')
    response = target.read()
    if ord(response[0]) != 0xA4:
        # Bad response, just skip
        print("Bad response: {:02X}".format(ord(response[0])))
        continue
    
    tc.add_trace(scope.get_last_trace(), text, "", key)

project.traceManager().appendSegment(tc)

Analysis

Now that we have our traces, we can go ahead and perform the attack. As described in the background theory, we'll have to do two attacks - one to get the 14th round key, and another (using the first result) to get the 13th round key. Then, we'll do some post-processing to finally get the 256 bit encryption key.

14th Round Key

We can attack the 14th round key with a standard, no-frills CPA attack (using the inverse sbox, since it's a decryption that we're breaking):

In [12]:
import chipwhisperer as cw
import chipwhisperer.analyzer as cwa

tm = project.trace_manager()
leak_model = cwa.AES128(cwa.aes128leakage.InvSBox_output)
attack = cwa.cpa(tm, leak_model)

With the setup done, we can actually preform the attack. 11000 samples is a rather large amount to chew through, so if you want a faster attack you can use a smaller range in attack.setPointRange(). (2900, 4200) will work for XMEGA, while (1400, 2600) will work for the STM32F3 (CWLite ARM).

In [13]:
key = [0xea, 0x79, 0x79, 0x20, 0xc8, 0x71, 0x44, 0x7d, 0x46, 0x62, 0x5f, 0x51, 0x85, 0xc1, 0x3b, 0xcb]

cb = cwa.get_jupyter_callback(attack)
if PLATFORM == "CWLITEARM" or PLATFORM == "CW308_STM32F3":
    attack.set_point_range((1400, 2600))
elif PLATFORM == "CWLITEXMEGA" or PLATFORM == "CW303":
    pass
attack_results = attack.process_traces(cb)
Finished traces 190 to 200
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
PGE= 181 206 36 206 40 120 241 174 226 110 55 215 19 20 133 148
0 EA
0.884
79
0.895
79
0.853
20
0.882
C8
0.872
71
0.878
44
0.879
7D
0.861
46
0.880
62
0.872
5F
0.868
51
0.868
85
0.887
C1
0.855
3B
0.864
CB
0.904
1 EC
0.334
38
0.320
8B
0.330
BC
0.340
8E
0.336
84
0.334
95
0.392
0D
0.385
26
0.334
B3
0.364
A2
0.337
77
0.355
86
0.335
5D
0.357
31
0.353
CA
0.365
2 E9
0.332
7B
0.303
78
0.325
BD
0.336
2E
0.327
3B
0.314
40
0.312
4B
0.366
63
0.333
8D
0.350
3D
0.324
3F
0.344
84
0.315
5E
0.313
75
0.343
E9
0.348
3 72
0.320
D5
0.302
7D
0.321
6E
0.323
B3
0.315
9E
0.312
0E
0.309
AC
0.335
7D
0.330
90
0.333
22
0.313
69
0.321
DA
0.312
76
0.313
A0
0.321
A3
0.336
4 8B
0.312
A3
0.301
1E
0.319
45
0.322
E5
0.308
E4
0.304
C7
0.308
33
0.326
E1
0.328
D1
0.328
3E
0.307
1F
0.318
A0
0.308
7D
0.303
DA
0.315
20
0.330
In [14]:
rec_key = []
for bnum in attack_results.find_maximums():
    rec_key.append(bnum[0][0])
    print("Best Guess = 0x{:02X}, Corr = {}".format(bnum[0][0], bnum[0][2]))
Best Guess = 0xEA, Corr = 0.8837789858874526
Best Guess = 0x79, Corr = 0.8950453642806259
Best Guess = 0x79, Corr = 0.8527194056547216
Best Guess = 0x20, Corr = 0.8823517337678705
Best Guess = 0xC8, Corr = 0.8723529731942391
Best Guess = 0x71, Corr = 0.8779701857583313
Best Guess = 0x44, Corr = 0.8792486390034585
Best Guess = 0x7D, Corr = 0.861049310196028
Best Guess = 0x46, Corr = 0.8797961274413335
Best Guess = 0x62, Corr = 0.8719070443390665
Best Guess = 0x5F, Corr = 0.867951794965088
Best Guess = 0x51, Corr = 0.868297905550828
Best Guess = 0x85, Corr = 0.8872928018579017
Best Guess = 0xC1, Corr = 0.8554844066280056
Best Guess = 0x3B, Corr = 0.8643321301558302
Best Guess = 0xCB, Corr = 0.904281980227047

13th Round Key

Analyzer doesn't have a leakage model for the 13th round key built in, so we'll need to create our own. An example class is shown below along with the beginning of the setup. NOTE: You'll need to update calc_round_key with the key you found in the last step

In [15]:
import chipwhisperer as cw

class AES256_Round13_Model(cwa.aes128leakage.AESLeakageHelper):
    def leakage(self, pt, ct, guess, bnum):
        #You must put YOUR recovered 14th round key here - this example may not be accurate!
        calc_round_key = [0xea, 0x79, 0x79, 0x20, 0xc8, 0x71, 0x44, 0x7d, 0x46, 0x62, 0x5f, 0x51, 0x85, 0xc1, 0x3b, 0xcb]
        xored = [calc_round_key[i] ^ pt[i] for i in range(0, 16)]
        block = xored
        block = self.inv_shiftrows(block)
        block = self.inv_subbytes(block)
        block = self.inv_mixcolumns(block)
        block = self.inv_shiftrows(block)
        result = block
        return self.inv_sbox((result[bnum] ^ guess[bnum]))
    

leak_model = cwa.AES128(AES256_Round13_Model)
attack = cwa.cpa(tm, leak_model)

Resyncing Traces (XMEGA Only)

The traces for the XMEGA version of the firmware become desynced around sample 7000. This is due to a non-constant AES implementation: the code does not always take the same amount of time to run for every input. (It's actually possible to do a timing attack on this AES implementation! We'll stick with our CPA attack for now.)

While this does open up a timing attack, it actually makes our AES attack a little harder, since we'll have to resync the traces. Luckily, this can be done pretty easily by using the ResyncSAD preprocessing module:

In [16]:
if PLATFORM == "CWLITEXMEGA" or PLATFORM == "CW303":
    resync_traces = cwa.preprocessing.ResyncSAD(tm, connectTracePlot=False)
    resync_traces.enabled = True
    resync_traces.ref_trace = 0
    resync_traces.target_window = (9100, 9300)
    resync_traces.max_shift = 200
    attack.set_trace_source(resync_traces)

Running the Attack

Like in the 14th round attack, we can use a smaller range of points to make the attack faster. (8000,10990) works well for the XMEGA, while (6500, 8500) works well for the STM32F3.

In [17]:
if PLATFORM == "CWLITEARM" or PLATFORM == "CW308_STM32F3":
    attack.set_point_range((6500,8500))
elif PLATFORM == "CWLITEXMEGA" or PLATFORM == "CW303":
    attack.set_point_range((8000,10990))
cb = cwa.get_jupyter_callback(attack)
attack_results = attack.process_traces(cb)
Finished traces 190 to 200
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
PGE= 212 170 48 48 119 117 7 154 115 165 205 171 209 50 47 139
0 C6
0.885
BD
0.867
4E
0.877
50
0.873
AB
0.887
CA
0.904
75
0.839
77
0.866
79
0.904
87
0.887
96
0.869
CA
0.880
1C
0.892
7F
0.880
C5
0.871
82
0.908
1 71
0.357
FE
0.318
2D
0.367
60
0.351
7E
0.350
23
0.316
E9
0.369
51
0.391
11
0.326
E4
0.402
47
0.357
47
0.406
B0
0.364
23
0.351
5D
0.324
20
0.333
2 A8
0.349
A3
0.317
DF
0.365
80
0.343
AD
0.320
AF
0.315
CF
0.365
EA
0.333
1F
0.323
B7
0.332
39
0.345
FB
0.376
A2
0.348
E3
0.337
4B
0.317
53
0.326
3 DE
0.344
CD
0.311
E2
0.341
18
0.327
03
0.310
FC
0.315
93
0.363
FA
0.316
42
0.311
50
0.328
F5
0.342
46
0.324
E5
0.332
13
0.332
37
0.300
0F
0.326
4 35
0.343
56
0.311
B7
0.334
86
0.323
ED
0.304
6F
0.311
8A
0.343
D0
0.316
04
0.301
95
0.328
76
0.336
6D
0.324
9B
0.314
22
0.332
1D
0.299
81
0.324

You can run the block below and the correct key should be printed out:

In [18]:
rec_key2 = []
for bnum in attack_results.find_maximums():
    print("Best Guess = 0x{:02X}, Corr = {}".format(bnum[0][0], bnum[0][2]))
    rec_key2.append(bnum[0][0])
Best Guess = 0xC6, Corr = 0.8852196922050869
Best Guess = 0xBD, Corr = 0.8671831969172145
Best Guess = 0x4E, Corr = 0.8765976578812721
Best Guess = 0x50, Corr = 0.8734989273608975
Best Guess = 0xAB, Corr = 0.8871307825381091
Best Guess = 0xCA, Corr = 0.9035063220324259
Best Guess = 0x75, Corr = 0.8388095773576352
Best Guess = 0x77, Corr = 0.8657347481835576
Best Guess = 0x79, Corr = 0.9041118273927382
Best Guess = 0x87, Corr = 0.887193591592365
Best Guess = 0x96, Corr = 0.8692473331767596
Best Guess = 0xCA, Corr = 0.8801736591675976
Best Guess = 0x1C, Corr = 0.8923756701402263
Best Guess = 0x7F, Corr = 0.8795571924010404
Best Guess = 0xC5, Corr = 0.8713285774137516
Best Guess = 0x82, Corr = 0.9082832246667472

This, however, isn't actually the 13th round key. To get the real 13th round key, we'll need to run what we've recovered through a shiftrows() and mixcolumns() operation:

In [19]:
from chipwhisperer.analyzer.attacks.models.aes.funcs import shiftrows,mixcolumns
    
real_key2 = shiftrows(rec_key2)
real_key2 = mixcolumns(real_key2)

print("Recovered:", end="")
for subkey in real_key2:
    print(" {:02X}".format(subkey), end="")
print("")
Recovered: C6 6A A6 12 4A BA 4D 04 4A 22 03 54 5B 28 0E 63

We now have everything we need to recover the full key! We'll start by combining the 13th and 14th round keys:

In [20]:
rec_key_comb = real_key2.copy()
rec_key_comb.extend(rec_key)

print("Key:", end="")
for subkey in rec_key_comb:
    print(" {:02X}".format(subkey), end="")
print("")
Key: C6 6A A6 12 4A BA 4D 04 4A 22 03 54 5B 28 0E 63 EA 79 79 20 C8 71 44 7D 46 62 5F 51 85 C1 3B CB

and then we can use the AES128_8bit leakage model to recover the first two rounds:

In [21]:
btldr_key = leak_model.keyScheduleRounds(rec_key_comb, 13, 0)
btldr_key.extend(leak_model.keyScheduleRounds(rec_key_comb, 13, 1))
print("Key:", end="")
for subkey in btldr_key:
    print(" {:02X}".format(subkey), end="")
print("")
Key: 94 28 5D 4D 6D CF EC 08 D8 AC DD F6 BE 25 A4 99 C4 D9 D0 1E C3 40 7E D7 D5 28 D4 09 E9 F0 88 A1

You should see a 32 byte key printed out. Open supersecret.h, confirm that we have the right key, and celebrate!

Recovering the IV

Now that we have the encryption key, we can proceed onto an attack of the next secret value: the IV.

Here, we have the luxury of seeing the source code of the bootloader. This is generally not something we would have access to in the real world, so we'll try not to use it to cheat. (peeking at supersecret.h counts as cheating). Instead, we'll use the source to help us identify important parts of the power traces.

Bootloader Source Code

Inside the bootloader's main loop, it does three tasks that we're interested in:

  • it decrypts the incoming ciphertext;
  • it applies the IV to the decryption's result; and
  • it checks for the signature in the resulting plaintext.

This snippet from bootloader.c shows all three of the tasks:

// Continue with decryption
trigger_high();                
aes256_decrypt_ecb(&ctx, tmp32);
trigger_low();

// Apply IV (first 16 bytes)
for (i = 0; i < 16; i++){
    tmp32[i] ^= iv[i];
}

//Save IV for next time from original ciphertext                
for (i = 0; i < 16; i++){
    iv[i] = tmp32[i+16];
}

// Tell the user that the CRC check was okay
putch(COMM_OK);
putch(COMM_OK);

//Check the signature
if ((tmp32[0] == SIGNATURE1) &&
   (tmp32[1] == SIGNATURE2) &&
   (tmp32[2] == SIGNATURE3) &&
   (tmp32[3] == SIGNATURE4)){

   // Delay to emulate a write to flash memory
   _delay_ms(1);
}

This gives us a pretty good idea of how the microcontroller is going to do its job, but if you'd like to go further, you can open the .lss file for the binary that was built. This is called a listing file and it lets you see the assembly that the C was compiled and linked to.

Power Traces

As you can see from both files, after the decryption process, the bootloader executes a few distinct pieces of code:

  • To apply the IV, it uses an XOR operation;
  • To store the new IV, it copies the previous ciphertext into the IV array;
  • It sends two bytes on the serial port;
  • It checks the bytes of the signature one by one.

We should be able to recognize these four parts of the code in the power traces. Let's modify our capture routine to find them:

  1. We're looking for the original IV, but it's overwritten after each successful decryption. This means we'll have to reset the target before each trace we capture
  2. We'd like to skip over all of the decryption process. Recall that the trigger pin is set low after the decryption finishes. This means we can skip over the AES-256 function by triggering on a falling edge instead
  3. Depending on the target, we may have to flush the target's serial lines by sending it a bunch of invalid data and looking for a bad CRC return. This slows down the capture process by a lot, so you may want to try without doing this first.
  4. We won't need as many samples, so we can reduce how many we capture. 3000 should be sufficient for most targets.

Let's start by reducing our samples and making a function to reset our target (depending on your target, you may need to change the reset pin):

In [22]:
import time
scope.adc.samples = 3000

We can trigger on a falling edge by changing scope.adc.basic_mode to "falling_edge":

In [23]:
scope.adc.basic_mode = "falling_edge"

We can flush the serial line by sending an invalid message, then checking for a bad CRC return value (0xA1). Let's make sure our changes work by getting a trace:

In [24]:
from bokeh.plotting import figure, show
from bokeh.io import output_notebook
reset_target(scope)
message = [0x00]

target.read()

key, text = ktp.new_pair()  # manual creation of a key, text pair can be substituted here

message.extend(text)

crc = bl_crc.bit_by_bit(text)
message.append(crc >> 8)
message.append(crc & 0xFF)

#flush target's serial
okay = 0
while not okay:
    target.write("\0xxxxxxxxxxxxxxxxxx")
    time.sleep(0.005)
    response = target.read()
    if response:
        if ord(response[0]) == 0xA1:
            okay = 1

scope.arm()

target.write(message)
ret = scope.capture()
if ret:
    print('Timeout happened during acquisition')

# run aux stuff that should happen after trace here
response = target.read()
if ord(response[0]) != 0xA4:
    # Bad response, just skip
    print("Bad response: {:02X}".format(ord(response[0])))

trace = scope.get_last_trace()

output_notebook()
p = figure()

xrange = range(len(trace))
p.line(xrange, trace, line_color="red")
show(p)
Loading BokehJS ...

You should see 5 different sections:

  • 16 XORs
  • 16 register loads (this is the new IV being copied over)
  • Some serial communication
  • The signature check
  • The serial line going idle

Different targets have different power traces (for example, on Arm the XORs and register loads are almost identical), but hopefully you can pick out where each section is. For example, on XMEGA:

XMEGA_Bonus_Trace

With all of these things clearly visible, we have a pretty good idea of how to attack the IV and the signature. We should be able to look at each of the XOR spikes to find each of the IV bytes - each byte is processed on its own. Then, the signature check uses a short-circuiting comparison: as soon as it finds a byte in error, it stops checking the remaining bytes. This type of check is susceptible to a timing attack.

With those things done, we can move onto our capture loop. It's pretty similar to our last one. We're done with Analyzer, so we can store our traces in Python lists (we'll convert to numpy arrays later for easy analysis).

In [25]:
from tqdm import tnrange
import numpy as np
import time
traces = []
keys = []
plaintexts = []
if PLATFORM == "CWLITEARM" or PLATFORM == "CW308_STM32F3":
    N = 100  # Number of traces
elif PLATFORM == "CWLITEXMEGA" or PLATFORM == "CW303":
    N=250
for i in tnrange(N, desc='Capturing traces'):
    reset_target(scope)
    message = [0x00]
    
    target.read()
    
    key, text = ktp.new_pair()  # manual creation of a key, text pair can be substituted here
    keys.append(key)
    plaintexts.append(text)
    
    message.extend(text)
    
    crc = bl_crc.bit_by_bit(text)
    message.append(crc >> 8)
    message.append(crc & 0xFF)
    
    okay = 0
    while not okay:
        target.write("\0xxxxxxxxxxxxxxxxxx")
        time.sleep(0.005)
        response = target.read()
        if response:
            if ord(response[0]) == 0xA1:
                okay = 1
    scope.arm()

    target.write(message)
    ret = scope.capture()
    if ret:
        print('Timeout happened during acquisition')
        continue
        
    response = target.read()
    if ord(response[0]) != 0xA4:
        # Bad response, just skip
        print("Bad response: {:02X}".format(ord(response[0])))
        continue
    
    traces.append(scope.get_last_trace())

Analysis

Attack Theory

The bootloader applies the IV to the AES decryption result by calculating

$\text{PT} = \text{DR} \oplus \text{IV}$

where DR is the decrypted ciphertext, IV is the secret vector, and PT is the plaintext that the bootloader will use later. We only have access to one of these: since we know the AES-256 key, we can calculate DR. This exclusive or should be visible in the power traces

This is enough information for us to attack a single bit of the IV. Suppose we only wanted to get the first bit (number 0) of the IV. We could do the following:

  • Split all of the traces into two groups: those with DR[0] = 0, and those with DR[0] = 1.
  • Calculate the average trace for both groups.
  • Find the difference between the two averages. It should include a noticeable spike during the first iteration of the loop.
  • Look at the direction of the spike to decide if the IV bit is 0 (PT[0] = DR[0]) or if the IV bit is 1 (PT[0] = ~DR[0]).

This is effectively a DPA attack on a single bit of the IV. We can repeat this attack 128 times to recover the entire IV.

A 1-Bit Attack

Recall that we're looking for the xor operation between the last decrypted block, so we'll need to decrypt it up to that point. The PyCrypto includes an AES decryption routine, so we'll be using that. We'll start by importing the necessary modules and converting our traces/plaintext to numpy arrays:

In [26]:
from Crypto.Cipher import AES
import numpy as np

trace_array = np.asarray(traces)  # if you prefer to work with numpy array for number crunching
textin_array = np.asarray(plaintexts)

numTraces = len(trace_array)
traceLen = len(trace_array[0])

Next we'll do the AES256 decryption. If you got a different key in the earlier part, you'll need to change knownkey.

In [27]:
knownkey = [0x94, 0x28, 0x5D, 0x4D, 0x6D, 0xCF, 0xEC, 0x08, 0xD8, 0xAC, 0xDD, 0xF6, 0xBE, 0x25, 0xA4, 0x99,
            0xC4, 0xD9, 0xD0, 0x1E, 0xC3, 0x40, 0x7E, 0xD7, 0xD5, 0x28, 0xD4, 0x09, 0xE9, 0xF0, 0x88, 0xA1]

knownkey = bytes(knownkey)
dr = []
aes = AES.new(knownkey, AES.MODE_ECB)
for i in range(numTraces):
    ct = bytes(textin_array[i])
    pt = aes.decrypt(ct)
    d = [bytearray(pt)[i] for i in range(16)]
    dr.append(d)

Now, let's split the traces into two groups by comparing bit 0 of the DR:

In [28]:
groupedTraces = [[] for _ in range(2)]
for i in range(numTraces):
    bit0 = dr[i][0] & 0x01
    groupedTraces[bit0].append(trace_array[i])
print(len(groupedTraces[0]))
53

If you have 1000 traces, you should expect this to print a number around 500 - roughly half of the traces should fit into each group. Now, NumPy's average function lets us easily calculate the average at each point:

In [29]:
# Find averages and differences
means = []
for i in range(2):
    means.append(np.average(groupedTraces[i], axis=0))
diff = means[1] - means[0]

Finally, we can plot this difference to see if we can spot the IV:

In [30]:
# Split traces into 2 groups
from bokeh.plotting import figure, show
from bokeh.io import output_notebook

output_notebook()
p = figure()

xrange = range(len(diff))
xrange2 = range(len(traces[0]))
p.line(xrange, diff, line_color="red")
#p.line(xrange2, traces[0], line_color='blue')
show(p)
Loading BokehJS ...

You should see a few visible spikes. We're looking for the XOR for byte 0 here, so any later spikes won't be the XOR. Use bokeh's zoom functionality to pinpoint all the largest spikes and record their sample location. You'll probably need to record a few: only one is the correct spike, but we won't be able to tell until we repeat this with other bytes. For example, you might have spikes at 37, 41, and 45. Make sure you record all these values. These peaks won't all be above 0, so make sure you're looking at both positive and negative values.

Next, we'll need to repeat this with a few more bytes. To make things easier, the necessary code has been combined into the below block. Increment the 0 in bit0 = dr[i][0] & 0x01 to other numbers to attack other bytes. Attacking bytes 0 through 3 should be sufficient.

In [31]:
def get_diff_plot(bit):
    groupedTraces = [[] for _ in range(2)]
    for i in range(numTraces):
        bit0 = dr[i][bit] & 0x01
        groupedTraces[bit0].append(trace_array[i])
    print(len(groupedTraces[0]))

    # Find averages and differences
    means = []
    for i in range(2):
        means.append(np.average(groupedTraces[i], axis=0))
    diff = means[1] - means[0]
    return diff
# Split traces into 2 groups
diffs = [get_diff_plot(0), get_diff_plot(1), get_diff_plot(2), get_diff_plot(3), get_diff_plot(4)]
from bokeh.plotting import figure, show
from bokeh.io import output_notebook

output_notebook()
p = figure()

xrange = range(len(diffs[0]))
p.line(xrange, diffs[0], line_color="red")
p.line(xrange, diffs[2] - 6E-3, line_color="green")
p.line(xrange, diffs[1] - 3E-3, line_color="blue")
p.line(xrange, diffs[3] - 9E-3, line_color="purple")
p.line(xrange, diffs[4] - 12E-3, line_color="yellow")

show(p)
53
41
40
52
56
Loading BokehJS ...

Now that you have some peak data, you'll want to use this to find the time shift between XORs. This time shift should be constant between samples and needs to work for all samples (each run through the loop is the same, so it makes sense that the time shift should be constant). For example, you might have:

0th byte @ 37, 41
1st byte @ 77, 81
2nd byte @ 105, 117, 121
3rd byte @ 141, 157, 161
4th byte @ 197, 201

With this data, peaks at 41, 81, 121, 161, and 201 have a constant time shift of 40. This means the location of the XORs is 41 + 40 * byte#

The Other 127

The best way to attack the IV would be to repeat the 1-bit conceptual attack for each of the bits. Try to do this yourself! (Really!) If you're stuck, here are a few hints to get you going:

One easy way of looping through the bits is by using two nested loops, like this:

for byte in range(16):
    for bit in range(8):
        # Attack bit number (byte*8 + bit)

The sample that you'll want to look at will depend on which byte you're attacking. We had success when we used location = 51 + byte*60, but your mileage will vary.

The bitshift operator and the bitwise-AND operator are useful for getting at a single bit:

# This will either result in a 0 or a 1
checkIfBitSet = (byteToCheck >> bit) & 0x01

If you're really, really stuck, the end of this tutorial has a working script. After finding the IV, check supersecret.h and verify that your attack was successful.

In [32]:
btldr_IV = [0] * 16
for byte in range(16):
    if PLATFORM == "CWLITEARM" or PLATFORM == "CW308_STM32F3":
        location = 41 + byte * 40
    elif PLATFORM == "CWLITEXMEGA" or PLATFORM == "CW303":
        location = 49 + byte * 60
    iv = 0
    for bit in range(8):
        pt_bits = [((dr[i][byte] >> (7-bit)) & 0x01) for i in range(numTraces)]

        # Split traces into 2 groups
        groupedPoints = [[] for _ in range(2)]
        for i in range(numTraces):
            groupedPoints[pt_bits[i]].append(trace_array[i][location])
            
        means = []
        for i in range(2):
            means.append(np.average(groupedPoints[i]))
        diff = means[1] - means[0]
        
        iv_bit = 1 if diff > 0 else 0
        iv = (iv << 1) | iv_bit
        
        print(iv_bit, end = " ")
        
    print("{:02X}".format(iv))
    btldr_IV[byte] = iv
    
print(btldr_IV)
1 1 0 0 0 0 0 1 C1
0 0 1 0 0 1 0 1 25
0 1 1 0 1 0 0 0 68
1 1 0 1 1 1 1 1 DF
1 1 1 0 0 1 1 1 E7
1 1 0 1 0 0 1 1 D3
0 0 0 1 1 0 0 1 19
1 1 0 1 1 0 1 0 DA
0 0 0 1 0 0 0 0 10
1 1 1 0 0 0 1 0 E2
0 1 0 0 0 0 0 1 41
0 1 1 1 0 0 0 1 71
0 0 1 1 0 0 1 1 33
1 0 1 1 0 0 0 0 B0
1 1 1 0 1 0 1 1 EB
0 0 1 1 1 1 0 0 3C
[193, 37, 104, 223, 231, 211, 25, 218, 16, 226, 65, 113, 51, 176, 235, 60]

Attacking the Signature

The last thing we can do with this bootloader is attack the signature. This final section will show how one byte of the signature could be recovered. If you want more of this kind of analysis, a more complete timing attack is shown in Tutorial B3-1 Timing Analysis with Power for Password Bypass.

Attack Theory

Recall from earlier that the signature check in C looks like:

if ((tmp32[0] == SIGNATURE1) &&
    (tmp32[1] == SIGNATURE2) &&
    (tmp32[2] == SIGNATURE3) &&
    (tmp32[3] == SIGNATURE4)){

In C, boolean expressions support short-circuiting. When checking multiple conditions, the program will stop evaluating these booleans as soon as it can tell what the final value will be. In this case, unless all four of the equality checks are true, the result will be false. Thus, as soon as the program finds a single false condition, it's done.

Open the listing file for your binary (.lss), find the signature check, and confirm that this is happening. For example, on the STM32F3, the assembly looks like this:

//Check the signature
                if ((tmp32[0] == SIGNATURE1) &&
 8000338:   f89d 3018   ldrb.w  r3, [sp, #24]
 800033c:   2b00        cmp r3, #0
 800033e:   d1c2        bne.n   80002c6 <main+0x52>
 8000340:   f89d 2019   ldrb.w  r2, [sp, #25]
 8000344:   2aeb        cmp r2, #235    ; 0xeb
 8000346:   d1be        bne.n   80002c6 <main+0x52>
                   (tmp32[1] == SIGNATURE2) &&
 8000348:   f89d 201a   ldrb.w  r2, [sp, #26]
 800034c:   2a02        cmp r2, #2
 800034e:   d1ba        bne.n   80002c6 <main+0x52>
                   (tmp32[2] == SIGNATURE3) &&
 8000350:   f89d 201b   ldrb.w  r2, [sp, #27]
 8000354:   2a1d        cmp r2, #29
 8000356:   d1b6        bne.n   80002c6 <main+0x52>
                   (tmp32[3] == SIGNATURE4)){

This assembly code confirms the short-circuiting operation. Each of the four assembly blocks include a comparison and a conditional branch. All four of the conditional branches (bne.n) return the program to the same location (the start of the while(1) loop). All four branches must fail to get into the body of the if block.

The short-circuiting conditions are perfect for us. We can use our power traces to watch how long it takes for the signature check to fail. If the check takes longer than usual, then we know that the first byte of our signature was right.

Power Traces

Our capture loop will be pretty similar to the one we used to break the IV, but now that we know the secret values of the encryption process we can make some improvements by encrypting the text that we send. This has two important advantages:

  1. We can control the signature. We could reuse the traces we took during the IV attack, but this way ensures that we hit each possible value once. It also simplifies the analysis, since we don't have to worry about decrypting the text we sent.
  2. We no longer have to reset after each attempt, since we know what the next IV is going to be (we do need to reset at the beginning to make sure we're on the same starting IV as the target). This speeds up the capture process considerably.

To perform the AES256 CBC encryption, there's a few steps we need to take:

  1. XOR the IV with the text we want to send
  2. Encrypt this new text
  3. Set this cipher text as the new IV

We can use PyCrypto again to make the encryption process easy and the other two steps are simple operations. We'll run our loop 256 times (one for each possible byte value) and assign that value to the byte we want to check. We're not quite sure where the check is happening, so we'll be safe and capture 24000 traces. Everthing else should look familiar from earlier parts of the tutorial:

In [33]:
from tqdm import tqdm
import numpy as np
from Crypto.Cipher import AES
import time

traces = []
keys = []
plaintexts = []

iv = [0xC1, 0x25, 0x68, 0xDF, 0xE7, 0xD3, 0x19, 0xDA, 0x10, 0xE2, 0x41, 0x71, 0x33, 0xB0, 0xEB, 0x3C]

knownkey = [0x94, 0x28, 0x5D, 0x4D, 0x6D, 0xCF, 0xEC, 0x08, 0xD8, 0xAC, 0xDD, 0xF6, 0xBE, 0x25, 0xA4, 0x99,
            0xC4, 0xD9, 0xD0, 0x1E, 0xC3, 0x40, 0x7E, 0xD7, 0xD5, 0x28, 0xD4, 0x09, 0xE9, 0xF0, 0x88, 0xA1]

knownkey = bytes(knownkey)
aes = AES.new(knownkey, AES.MODE_ECB)
N = 256 # Number of traces

reset_target(scope)
okay=0
scope.adc.basic_mode = "falling_edge"
while not okay:
    target.write("\0xxxxxxxxxxxxxxxxxx")
    time.sleep(0.005)
    response = target.read()
    if response:
        if ord(response[0]) == 0xA1:
            okay = 1

scope.adc.samples = 24000
scope.adc.offset = 0
for byte in tnrange(N, desc='Attacking Signature Byte'):
    message = [0x00]
    text = [0] * 16
    
    # the 4 signature bytes
    text[0] = byte
    text[1] = 0
    text[2] = 0
    text[3] = 0
    
    target.read()
    
    textcpy = [0] * 16
    textcpy[:] = text[:]
    plaintexts.append(textcpy)
    
    # Apply IV
    for i in range(len(iv)):
        text[i] ^= iv[i]
    
    # Encrypt text
    ct = aes.encrypt(bytes(text))
    
    message.extend(ct)
    
    # Use ct as new IV
    iv[:] = ct[:]
    
    crc = bl_crc.bit_by_bit(ct)
    message.append(crc >> 8)
    message.append(crc & 0xFF)
    
    scope.arm()

    target.write(message)
    timeout = 50
    
    ret = scope.capture()
    if ret:
        print('Timeout happened during acquisition')
        continue
        
    response = target.read()
    if ord(response[0]) != 0xA4:
        # Bad response, just skip
        print("Bad response: {:02X}".format(ord(response[0])))
        continue
    
    traces.append(scope.get_last_trace())

Analysis

Now that we've captured our traces, the actual analysis is pretty simple. We're looking for a single trace that looks very different from the rest. A simple way to find this is to compare all the traces to a reference trace. We'll use the average of all the traces as our reference:

In [34]:
mean = np.average(traces, axis=0)

That leaves us with comparing the traces. Let's start by plotting the difference between some of the traces and the mean:

In [35]:
from bokeh.plotting import figure, show
from bokeh.io import output_notebook

output_notebook()
p = figure()
colors = ["red", "blue", "green", "yellow"]
for i in range(0,10):
    p.line(range(len(traces[i])), traces[i]-mean, line_color=colors[i%4])
        
show(p)
Loading BokehJS ...

Depending on your target, you might have seen something like this:

Looks like we've found our trace! However, let's clean this up with some statistics. We can use the correlation coefficient to see which bytes are the furthest away from the average. We only want to take the correlation across where the plots differ, chose a subset of the plot where there's a large difference. In the case of the above picture, the difference starts at around 18k, and continues until the end. A range of 18000 to 20000 should work nicely:

In [36]:
corr = []
for i in range(256):
    corr.append(np.corrcoef(mean[18000:20000], traces[i][18000:20000])[0, 1])
print(np.sort(corr))
print(np.argsort(corr))
[0.38788961 0.99883537 0.99886363 0.99887198 0.99889605 0.99903419
 0.99914477 0.99919587 0.9992226  0.99923041 0.99924501 0.99926074
 0.99927876 0.99929285 0.99929849 0.99931018 0.99932437 0.99937284
 0.99937458 0.99939182 0.99939984 0.99941487 0.99941844 0.99943108
 0.99943145 0.99944882 0.99946729 0.99946853 0.99946973 0.99948087
 0.99948482 0.99948682 0.99949434 0.99951335 0.99951567 0.99952221
 0.99952638 0.99953255 0.99954226 0.99954406 0.99955195 0.9995621
 0.99956455 0.99957965 0.99958202 0.99958914 0.99959134 0.99959263
 0.99959639 0.9995973  0.99959943 0.99960704 0.99960772 0.99961413
 0.99962982 0.99963252 0.99963665 0.99963883 0.99964204 0.99964259
 0.99964281 0.9996454  0.99964568 0.99964754 0.99964852 0.99965256
 0.99965611 0.99965687 0.99966108 0.99966774 0.99967082 0.99967301
 0.99967398 0.99968158 0.99968399 0.99968766 0.99968795 0.99968991
 0.99969061 0.99969219 0.99969299 0.99969353 0.99969582 0.99970117
 0.9997027  0.99970334 0.99970457 0.99970662 0.99970994 0.99971021
 0.99971086 0.99971172 0.99971421 0.99971582 0.99971651 0.99971854
 0.99971937 0.99972026 0.99972082 0.99972119 0.99972265 0.99972309
 0.99972509 0.9997254  0.9997258  0.99972666 0.9997294  0.99973138
 0.99973481 0.9997351  0.99973693 0.999737   0.99973714 0.99974228
 0.99974304 0.99974389 0.99974463 0.99974572 0.99974636 0.99974642
 0.99975105 0.99975116 0.99975458 0.99975577 0.99975631 0.99975769
 0.99975832 0.99975888 0.99975973 0.99976046 0.99976081 0.99976127
 0.99976257 0.99976378 0.9997644  0.999765   0.99976626 0.99976651
 0.99976726 0.99976825 0.99976934 0.99976978 0.9997711  0.99977133
 0.99977493 0.99977497 0.99977536 0.99977669 0.99977849 0.9997797
 0.99978088 0.99978192 0.99978269 0.99978325 0.99978474 0.99978604
 0.99978772 0.99978932 0.99978973 0.99979159 0.99979163 0.9997918
 0.99979235 0.99979263 0.99979576 0.99979813 0.99979888 0.99979914
 0.99980054 0.99980101 0.99980231 0.99980287 0.9998039  0.99980456
 0.99980573 0.99980597 0.99980666 0.99980725 0.9998095  0.99981024
 0.99981135 0.99981252 0.99981284 0.99981301 0.99981417 0.99981639
 0.9998167  0.99981773 0.99981787 0.99981821 0.99982001 0.99982008
 0.99982021 0.99982085 0.99982344 0.99982355 0.99982554 0.99982603
 0.99982605 0.99983009 0.99983043 0.99983048 0.99983112 0.99983212
 0.9998334  0.99983348 0.99983402 0.99983586 0.999836   0.99983676
 0.99983688 0.99983696 0.99983765 0.99983786 0.99983876 0.99983933
 0.9998397  0.99983983 0.99983999 0.99984025 0.99984102 0.99984443
 0.99984447 0.99984478 0.99984514 0.99984802 0.99984897 0.99985045
 0.99985262 0.99985263 0.9998528  0.99985822 0.99985828 0.99986037
 0.99986056 0.99986119 0.99986287 0.99986308 0.99986361 0.99986421
 0.9998667  0.99986727 0.99986745 0.99986866 0.99987007 0.99987104
 0.99987395 0.99987581 0.99987633 0.99987968 0.99988503 0.99988556
 0.9998861  0.99989104 0.99989207 0.9998966 ]
[  0 161 195 124 199   1   7 105 148  46 219  22  79 164  73  16 207  30
  98 197 106 186 121   3 141 211  20 238  17 155  11 145 208 251  99  14
 192 200 243 188  89 136  13  51 102 100   2 244   5  36  74 189 112  97
 180  54 196  56  48 159 216  15  50  71 103  33  24  93 142  88  55 232
  72 173 150 223 204  82 236 228 175  52 250  65 230 205 120  18 255 151
 198 126  43 246 129  96 252  64  32  10 176 185 179 194  90 119  94   9
 242  95  38  69   6 248 154  35  27  12 169 160  40 190 177 152  28  77
  47 210  25 212  49 170 245 147   8  84  23  44 226 217 201 166 109 254
  60 122 133 239 253   4 123 221 220  86 206  26 181 130 111 125  62 115
  68 108  61 158 214 183 224 227 149 222  34  39  19  87 113 174 187 191
 138 137 157  42 202 203 237  81 235 172 229 144 184  75 139 233 231  70
 171 225  80 249 213  31 247 131  29 168 114  53  37 165 140 162  76 153
 134 163 101  83 234  78 116 110  66  85 218 193 182 241  58  92 215 132
  21  57 118 104  91  59 146  45 128  63 156 117 167 209 240 135 107 143
  67 178 127  41]

This output tells us two things:

  • The first list says that almost every trace looks very similar to the overall mean (98% correlated or higher). However, there's one trace that is totally different, with 68% correlation. This is probably our correct guess.
  • The second list gives the signature guess that matches each of the above correlations. The first number in the list is 0x00, which is the correct signature!

To finish this attack, change the capture loop to keep the first byte fixed and vary the second byte instead. Repeat this with the rest of the bytes and you should have the signature.

In [37]:
from tqdm import tqdm
import numpy as np
from Crypto.Cipher import AES
import time

traces = []
keys = []
plaintexts = []
btldr_sig = [0] * 4

iv = [0xC1, 0x25, 0x68, 0xDF, 0xE7, 0xD3, 0x19, 0xDA, 0x10, 0xE2, 0x41, 0x71, 0x33, 0xB0, 0xEB, 0x3C]

knownkey = [0x94, 0x28, 0x5D, 0x4D, 0x6D, 0xCF, 0xEC, 0x08, 0xD8, 0xAC, 0xDD, 0xF6, 0xBE, 0x25, 0xA4, 0x99,
            0xC4, 0xD9, 0xD0, 0x1E, 0xC3, 0x40, 0x7E, 0xD7, 0xD5, 0x28, 0xD4, 0x09, 0xE9, 0xF0, 0x88, 0xA1]

knownkey = bytes(knownkey)
aes = AES.new(knownkey, AES.MODE_ECB)
N = 256 # Number of traces

reset_target(scope)
okay=0
scope.adc.basic_mode = "falling_edge"
while not okay:
    target.write("\0xxxxxxxxxxxxxxxxxx")
    time.sleep(0.005)
    response = target.read()
    if response:
        if ord(response[0]) == 0xA1:
            okay = 1
            
scope.adc.samples = 24000
scope.adc.offset = 0
for bnum in range(4):
    traces = []
    for byte in tnrange(N, desc='Attacking Signature Byte {}'.format(bnum)):
        message = [0x00]
        text = [0] * 16

        # the 4 signature bytes
        for j in range(bnum):
            text[j] = btldr_sig[j]
        text[bnum] = byte
        
        target.read()

        textcpy = [0] * 16
        textcpy[:] = text[:]
        plaintexts.append(textcpy)

        # Apply IV
        for i in range(len(iv)):
            text[i] ^= iv[i]

        # Encrypt text
        ct = aes.encrypt(bytes(text))

        message.extend(ct)

        # Use ct as new IV
        iv[:] = ct[:]

        crc = bl_crc.bit_by_bit(ct)
        message.append(crc >> 8)
        message.append(crc & 0xFF)

        scope.arm()
        target.write(message)
        ret = scope.capture()
        if ret:
            print('Timeout happened during acquisition')
            continue

        # run aux stuff that should happen after trace here
        response = target.read()
        if ord(response[0]) != 0xA4:
            # Bad response, just skip
            print("Bad response: {:02X}".format(ord(response[0])))
            continue

        traces.append(scope.get_last_trace())
        
    mean = np.average(traces, axis=0)
    corr = []
    for i in range(256):
        corr.append(np.corrcoef(mean[18000:20000], traces[i][18000:20000])[0, 1])
    btldr_sig[bnum] = np.argsort(corr)[0]




In [38]:
scope.dis()
target.dis()

Conclusion

We've now successfully recovered all of the secrets of the bootloader!

Tests

In [39]:
real_btldr_key = [0x94, 0x28, 0x5D, 0x4D, 0x6D, 0xCF, 0xEC, 0x08, 0xD8, 0xAC, 0xDD, 0xF6, 0xBE, 0x25, 0xA4, 0x99, \
                    0xC4, 0xD9, 0xD0, 0x1E, 0xC3, 0x40, 0x7E, 0xD7, 0xD5, 0x28, 0xD4, 0x09, 0xE9, 0xF0, 0x88, 0xA1]

real_btldr_IV = [0xC1, 0x25, 0x68, 0xDF, 0xE7, 0xD3, 0x19, 0xDA, 0x10, 0xE2, 0x41, 0x71, 0x33, 0xB0, 0xEB, 0x3C]

real_btldr_sig = [0x00, 0xEB, 0x02, 0x1D]
In [40]:
assert (btldr_key == real_btldr_key), "Attack on encryption key failed!\nGot: {}\nExp: {}".format(btldr_key, real_btldr_key)
In [41]:
assert (btldr_IV == real_btldr_IV), "Attack on IV failed!\nGot: {}\nExpected: {}".format(btldr_IV, real_btldr_IV)
In [42]:
assert (btldr_sig == real_btldr_sig), "Attack on signature failed!\nGot: {}\nExpected: {}".format(btldr_sig, real_btldr_sig)
In [ ]: